Análisis ETL: Ciclo Olímpico 2021-2024 en Natación
Autores/as
Alonso González Romero
Daniel López Paredes
Alba Martínez de la Hermosa
Fecha de publicación
13 de enero de 2026
1 Introducción
Este proyecto presenta un flujo de trabajo ETL (Extract, Transform, Load) aplicado a datos oficiales de competiciones internacionales de natación entre 2021 y 2024. La fuente de datos proviene de los registros oficiales de Omega Timing, que proporciona a través de archivos XML información referente a múltiples competiciones de nivel internacional (Europeos, Mundiales, Trials Americanos, TYR Pro Swim Series, entre otros).
El objetivo principal del análisis es transformar los datos crudos en información procesable que permita responder preguntas clave sobre el rendimiento de los nadadores, tendencias temporales y comparativas entre diferentes competiciones.
2 Organización y Gestión del Proyecto
Para la gestión del código se ha utilizado Git como herramienta de control de versiones y GitHub como plataforma de colaboración y compartición de código. El repositorio se estructura en carpetas para separar los datos crudos de las competiciones de natación, los datos procesados para su estructura en CSV, los scripts de ETL y la documentación del proyecto.
Por otro lado, se ha utilizado Github Projects para la gestión de tareas y seguimiento del progreso del proyecto, elaborando un tablero Kanban con diferentes columnas: - To Do. Tareas pendientes por realizar. - In Progress. Tareas en desarrollo. - Done. Tareas finalizadas.
Además para el desarrollo modular y concurrente de las diferentes funcionalidades del proyecto, se han establecido diferentes ramas en el repositorio: - main: Rama principal con la versión estable del proyecto. - develop: Rama de desarrollo donde se integran las nuevas funcionalidades antes de ser fusionadas a main. - feature/nombre-funcionalidad: Ramas específicas para el desarrollo de nuevas características o mejoras.
Para cada issue creado en GitHub Projects, se le asigna un Pull Request (PR) asociado, que es revisado por al menos otro miembro del equipo antes de ser fusionado a la rama develop o main. Esto asegura la calidad del código y facilita la colaboración entre los miembros del equipo. En la Figura 1 se puede observar el tablero mencionado junto con los issues vinculados a cada tarea. Una vez completado el merge de la feature branch a develop o main, el issue se mueve a la columna Done junto con el PR cerrado.
Figura 1: Tablero Kanban con issues gestionados en GitHub Projects.
Por otro lado en cuanto a las funciones de cada miembro del proyecto han sido las siguientes: - Alba Martínez de la Hermosa: encargada de desarrollar el análisis de los datos y plantear preguntas y visualizaciones para extraer la máxima información de los datos. - Alonso González Romero: experto en el dominio del problema, encargado de la extracción de los datos, así como de la correcta visualización de los resultados. - Daniel López Paredes: encargado de las transformaciones en el proceso ETL y de la gestión del proyecto en GitHub mediante el uso de ramas, creación de issues, revisión de pull requests (PR), etc.
Es importante destacar que todos los miembros del proyecto utilizan Git y Github para hacer commits, subir cambios al repositorio y crear ramas para cada funcionalidad desarrollada a lo largo del proyecto. Esto facilita la colaboración y el seguimiento de la evolución del proyecto.
3 Extracción de Datos
3.1 Importación de fuente de datos
Como bien se ha adelantado en la sección Introducción, los datos han sido extraídos de los archivos XML oficiales proporcionados por Omega Timing. Tras un proceso previo de scraping y parseo de archivos XML, hemos consolidado la información en cuatro archivos CSV que conforman nuestra base de datos relacional:
competiciones.csv: Información sobre las competiciones cargadas.
atletas.csv: Detalles de los nadadores.
clubes.csv: Datos de los clubes/países.
resultados.csv: Resultados de las competiciones nadadas.
A continuación, procedemos a la carga de estos archivos en el entorno de trabajo utilizando la librería pandas de Python y un manejo básico de errores para asegurar que los archivos se cargan correctamente:
import pandas as pdimport osBASE_PATH ='data/processed_data/'path_atletas =f'{BASE_PATH}atletas.csv'path_clubes =f'{BASE_PATH}clubes.csv'path_competiciones =f'{BASE_PATH}competiciones.csv'path_resultados =f'{BASE_PATH}resultados.csv'# Carga de DataFramestry: df_atletas = pd.read_csv(path_atletas) df_clubes = pd.read_csv(path_clubes) df_competiciones = pd.read_csv(path_competiciones) df_resultados = pd.read_csv(path_resultados)print("Todos los archivos se han cargados correctamente.")exceptFileNotFoundErroras e:print(f"Error de carga: {e}")# Eliminamos las rutas de los archivos para no sobrecargar el entornodel path_atletas, path_clubes, path_competiciones, path_resultados, BASE_PATH
Todos los archivos se han cargados correctamente.
3.2 Exploración inicial
Una vez cargados los datos, realizamos una exploración inicial de cada DataFrame para entender su estructura, los tipos de datos y la integridad de los identificadores. Para sistematizar este proceso de exploración, hemos desarrollado las siguientes funciones:
explorar_estructura(df, nombre_df, filas=3): Muestra las dimensiones, tipos de datos y un ejemplo de las primeras N filas de la tabla. Si no se especifica la última variable, se muestran las primeras 3 filas por defecto.
verificar_unicidad(df, col_id): Verifica si una columna específica actúa como identificador único (Clave Primaria).
def explorar_estructura(df, nombre_df, filas=3):""" Datos de entrada: - df: DataFrame a explorar. - nombre_df: Nombre descriptivo del DataFrame. (String) - filas: Número de filas a mostrar del DataFrame. (Integer, default=3) Funcionalidad: Muestra las dimensiones, tipos de datos y un ejemplo de las primeras filas. """print(f"### Estructura del dataset: {nombre_df}")print(f"Dimensiones: {df.shape[0]} filas x {df.shape[1]} columnas")# Creamos un resumen rápido de tiposprint("\n--- Tipos de datos ---")print(df.dtypes)print(f"\n--- Primeras {filas} filas de {nombre_df} ---") display(df.head(filas))print("\n"+"="*50+"\n")def verificar_unicidad(df, col_id, cols_entidad=None):""" Datos de entrada: - df: DataFrame a explorar. - col_id: Nombre de la columna a verificar como identificador único. (String) - cols_entidad (Opcional): Lista de columnas que definen a la entidad real (ej: ['nombre', 'apellidos', 'fecha_nacimiento']). Funcionalidad: 1. Verifica si col_id es único. 2. Si se provee cols_entidad, verifica si una misma entidad tiene múltiples IDs diferentes. """print(f"Verificando identificador '{col_id}':")try:# 1. Verificación básica de ID (Tu código original) serie = df[col_id]if serie.is_unique:print(f"La columna '{col_id}' es un identificador único (técnicamente).")else: total =len(df) unicos = serie.nunique()print(f"La columna '{col_id}' NO es única. Tiene {unicos} valores únicos de {total} registros.")# 2. Verificación de Integridad de Entidad (Nueva funcionalidad)if cols_entidad:print(f" ↳ Analizando duplicidad lógica en: {cols_entidad}...")# Agrupamos por los datos de la persona y contamos cuántos IDs únicos tiene cada una duplicidad = df.groupby(cols_entidad)[col_id].nunique()# Filtramos aquellos que tengan más de 1 ID asociado casos_problematicos = duplicidad[duplicidad >1]iflen(casos_problematicos) >0:print(f"¡ALERTA! Se encontraron {len(casos_problematicos)} personas/entidades con MÚLTIPLES IDs distintos.")print(f" Ejemplo: {casos_problematicos.index[0]} tiene {casos_problematicos.iloc[0]} IDs diferentes.")else:print(f"Integridad correcta: Cada persona (combinación de campos) tiene un solo ID asignado.")exceptKeyErroras e:# Detecta si falla el ID o las columnas de entidadprint(f"ERROR DE ACCESO: Alguna columna no existe en el DataFrame. Detalle: {e}")exceptExceptionas e:print(f"ERROR INESPERADO: {e}")finally:print("-"*30)
3.2.1 Atletas
Comenzamos con el DataFrame df_atletas, que contiene información sobre los nadadores. Analizamos primeramente su estructura:
explorar_estructura(df_atletas, 'ID', filas=5)
### Estructura del dataset: ID
Dimensiones: 4710 filas x 5 columnas
--- Tipos de datos ---
ID int64
NOMBRE object
APELLIDOS object
birthday object
género object
dtype: object
--- Primeras 5 filas de ID ---
Se puede observar que el DataFrame df_atletas tiene 4,710 filas y 5 columnas. Para cada nadador, se registra su identificador (ID), su nombre (NOMBRE), sus apellidos (APELLIDOS), su fecha de nacimiento (birthday) y su género (género).
Verificamos si la columna ID es un identificador único para este DataFrame:
Verificando identificador 'ID':
La columna 'ID' es un identificador único (técnicamente).
↳ Analizando duplicidad lógica en: ['NOMBRE', 'APELLIDOS', 'birthday', 'género']...
¡ALERTA! Se encontraron 373 personas/entidades con MÚLTIPLES IDs distintos.
Ejemplo: ('Aaron', 'BALTAYTIS', '2005-10-12', 'M') tiene 2 IDs diferentes.
------------------------------
Por lo tanto, podemos concluir que en el DataFrame df_atletas, la columna ID actúa como clave primaria pero hay atletas que tienen asignado el mismo ID.
3.2.2 Clubes
Procedemos de manera similar con el Dataframe df_clubes, que contiene información sobre los clubes o países a los que pertenecen los nadadores.
explorar_estructura(df_clubes, 'Clubes')
### Estructura del dataset: Clubes
Dimensiones: 1013 filas x 3 columnas
--- Tipos de datos ---
club_code object
club_name object
club_nation object
dtype: object
--- Primeras 3 filas de Clubes ---
El DataFrame df_clubes tiene 1013 filas y 3 columnas. Cada registro contiene el identificador del club (club_code), el nombre del club o país (club_name) y su código internacional (club_nation). Podemos observar que los club_code y club_nation contienen el nombre con 3 letras del país.
Verificamos si la columna club_code es un identificador único para este DataFrame:
verificar_unicidad(df_clubes, 'club_code')
Verificando identificador 'club_code':
La columna 'club_code' es un identificador único (técnicamente).
------------------------------
Por lo tanto, podemos concluir que en el DataFrame df_clubes, la columna club_code actúa como clave primaria y no hay errores en la carga.
3.2.3 Competiciones
Procedemos de manera similar con el Dataframe df_competiciones, que contiene información sobre las competiciones de natación.
### Estructura del dataset: Competiciones
Dimensiones: 21 filas x 9 columnas
--- Tipos de datos ---
ID int64
nombre object
ciudad object
tipo_piscina object
fecha_inicio object
fecha_fin object
pais object
cronometraje object
numeroCalles int64
dtype: object
--- Primeras 3 filas de Competiciones ---
El DataFrame df_competiciones tiene 21 filas y 9 columnas. Cada registro contiene el identificador de la competición (ID), el nombre de la competición (nombre), la ciudad donde se celebró (ciudad), el tipo de piscina (tipo_piscina), el país (pais) y las fechas de inicio y fin (fecha_inicio, fecha_fin), el tipo de cronometraje (cronometraje) y el número de calles (numeroCalles).
Verificamos si la columna ID es un identificador único para este DataFrame:
verificar_unicidad(df_competiciones, 'ID')
Verificando identificador 'ID':
La columna 'ID' es un identificador único (técnicamente).
------------------------------
Por lo tanto, podemos concluir que en el DataFrame df_competiciones, la columna ID actúa como clave primaria y no hay errores en la carga.
3.2.4 Resultados
Procedemos de manera similar con el Dataframe df_resultados, que contiene información sobre los resultados de las competiciones.
Se observa que el DataFrame df_resultados tiene 179696 filas y 13 columnas. Cada registro contiene variables significantes para los resultados como el identificador de la competición (id_competicion), el identificador del nadador (id_atleta), el identificador del club o país (club_code), y datos sobre la prueba nadada, la ronda, el tiempo realizado, la fecha y hora a la que nadó, entre otros. Cabe destacar el formato en el que se presentan los resultados, ya que, por cada parcial que nadó el nadador, se genera un registro independiente en el DataFrame. Es por ello que, como se puede observar, las 2 primeras filas del DataFrame corresponden al resultado del nadador con id 16422 en el 100 BREAST de la ronda PRE, donde la primera fila contiene información sobre el paso por el primer parcial (50m) y la segunda fila contiene el tiempo acumulado en el paso por el segundo y último parcial (100m).
4 Pre-procesamiento
Tras la exploración inicial de los datos, procedemos a la fase de pre-procesamiento, donde vamos a realizar las siguientes tareas:
Correción de errores de consistencia en los ID’s de atletas
Tratamiento de tipos de datos en las columnas
Manejo de valores nulos
Transformación de fechas
Transformaciones de texto
Creación de variables derivadas
4.1 Funciones Auxiliares
Para llevar a cabo el pre-procesamiento de manera sistemática y reutilizable, hemos desarrollado las siguientes funciones:
consolidar_ids_nadadores(df_atletas, df_resultados, col_id, col_id_2, cols_entidad): Unifica nadadores que tienen múltiples IDs basándose en sus datos de nombre, apellido etc…
verificar_nulos(df, nombre_df): Detecta y cuantifica valores faltantes en cada columna.
cambiar_tipos_datos(df, mapeo_tipos): Convierte tipos de datos según un diccionario especificado.
limpiar_espacios_blancos(df): Elimina espacios en blanco al inicio y final de valores texto.
transformar_fechas(df, columnas_fechas, columna_hora): Convierte columnas de fecha (y hora opcionalmente) a formato datetime.
mostrar_resumen_preprocesamiento(df, nombre_df): Muestra un resumen del estado del DataFrame tras el pre-procesamiento.
def consolidar_ids_nadadores(df_atletas, df_resultados, col_id, col_id_2, cols_entidad):""" Unifica nadadores que tienen múltiples IDs basándose en sus datos personales. Retorna: - df_atletas_limpio: DataFrame sin los IDs duplicados (filas eliminadas). - df_resultados_corregido: DataFrame con los IDs antiguos reemplazados por el unificado. """ df_a_new = df_atletas.copy() df_r_new = df_resultados.copy()print("--- Iniciando consolidación de IDs ---") grupos = df_a_new.groupby(cols_entidad)[col_id].unique() duplicados = grupos[grupos.apply(lambda x: len(x) >1)] mapa_cambios = {} ids_a_eliminar = []iflen(duplicados) ==0:print("No se encontraron duplicados. No hay cambios que hacer.")return df_a_new, df_r_newprint(f"Se encontraron {len(duplicados)} nadadores con múltiples IDs. Unificando...")for ids in duplicados: ids_ordenados =sorted(ids) id_superviviente = ids_ordenados[0] ids_sacrificables = ids_ordenados[1:]for id_malo in ids_sacrificables: mapa_cambios[id_malo] = id_superviviente ids_a_eliminar.append(id_malo) n_cambios = df_r_new[col_id_2].isin(mapa_cambios.keys()).sum() df_r_new[col_id_2] = df_r_new[col_id_2].replace(mapa_cambios)print(f"Se han actualizado {n_cambios} registros en la tabla de resultados.") total_antes =len(df_a_new) df_a_new = df_a_new[~df_a_new[col_id].isin(ids_a_eliminar)] total_despues =len(df_a_new)print(f"Se han eliminado {total_antes - total_despues} perfiles duplicados en la tabla de atletas.")print("-"*30)return df_a_new, df_r_newdef verificar_nulos(df, nombre_df):""" Datos de entrada: - df: DataFrame a analizar. - nombre_df: Nombre descriptivo del DataFrame. (String) Funcionalidad: Detecta y cuantifica valores nulos/faltantes en cada columna del DataFrame. """print(f"### Análisis de Valores Nulos: {nombre_df}") nulos_por_columna = df.isnull().sum() porcentaje_nulos = (df.isnull().sum() /len(df)) *100if nulos_por_columna.sum() ==0:print("No se detectaron valores nulos en el DataFrame.")else: resumen_nulos = pd.DataFrame({'Columna': nulos_por_columna.index,'Cantidad': nulos_por_columna.values,'Porcentaje': porcentaje_nulos.values }) resumen_nulos = resumen_nulos[resumen_nulos['Cantidad'] >0].sort_values('Cantidad', ascending=False)print(resumen_nulos.to_string(index=False))print("-"*50)def cambiar_tipos_datos(df, mapeo_tipos):""" Datos de entrada: - df: DataFrame a transformar. - mapeo_tipos: Diccionario con columnas y sus tipos destino. (Dict) Funcionalidad: Convierte el tipo de datos de las columnas especificadas. """print("### Conversión de Tipos de Datos")for columna, tipo in mapeo_tipos.items():if columna in df.columns:try: df[columna] = df[columna].astype(tipo)print(f"✓ Columna '{columna}' convertida a {tipo.__name__}")exceptExceptionas e:print(f"✗ Error al convertir '{columna}': {e}")else:print(f"⚠ Columna '{columna}' no encontrada en el DataFrame.")print("-"*50)return dfdef limpiar_espacios_blancos(df):""" Datos de entrada: - df: DataFrame a limpiar. Funcionalidad: Elimina espacios en blanco al inicio y final de valores de texto. """print("### Limpieza de Espacios en Blanco") columnas_procesadas =0for columna in df.select_dtypes(include=['object']).columns: df[columna] = df[columna].str.strip() columnas_procesadas +=1print(f"Se procesaron {columnas_procesadas} columnas de tipo texto.")print("-"*50)return dfdef transformar_fechas(df, columnas_fechas, columna_hora=None):""" Datos de entrada: - df: DataFrame a transformar. - columnas_fechas: Lista de columnas a convertir a datetime. (List) - columna_hora: (Opcional) Columna con hora para combinar con la primera fecha. (String) Funcionalidad: Convierte columnas especificadas al formato datetime. Si se proporciona columna_hora, combina fecha + hora en una nueva columna 'fecha_hora'. """print("### Transformación de Fechas")for columna in columnas_fechas:if columna in df.columns:try: df[columna] = pd.to_datetime(df[columna], errors='coerce')print(f"✓ Columna '{columna}' convertida a datetime")exceptExceptionas e:print(f"✗ Error al convertir '{columna}': {e}")else:print(f"⚠ Columna '{columna}' no encontrada.")# Si se proporciona una columna de hora, combinar fecha + horaif columna_hora and columna_hora in df.columns andlen(columnas_fechas) >0: columna_fecha = columnas_fechas[0]if columna_fecha in df.columns:try: df['fecha_hora'] = pd.to_datetime( df[columna_fecha].astype(str) +' '+ df[columna_hora].astype(str),format='%Y-%m-%d %H:%M:%S', errors='coerce' )print(f"✓ Columna 'fecha_hora' creada combinando '{columna_fecha}' y '{columna_hora}'")exceptExceptionas e:print(f"✗ Error al combinar fecha y hora: {e}")print("-"*50)return dfdef mostrar_resumen_preprocesamiento(df, nombre_df):""" Datos de entrada: - df: DataFrame procesado. - nombre_df: Nombre descriptivo del DataFrame. (String) Funcionalidad: Muestra un resumen del estado actual del DataFrame. """print(f"### Resumen Post-Procesamiento: {nombre_df}")print(f"Dimensiones: {df.shape[0]} filas x {df.shape[1]} columnas")print(f"Valores nulos totales: {df.isnull().sum().sum()}")print("\n--- Tipos de datos ---")print(df.dtypes)print("\n"+"="*50+"\n")
4.2 Consolidación de IDs de atletas
# Definimos qué columnas hacen única a una personacols_persona = ['NOMBRE', 'APELLIDOS', 'birthday', 'género']# Ejecutamos la función y SOBRESCRIBIMOS las variables antiguas con las nuevas versionesdf_atletas, df_resultados = consolidar_ids_nadadores( df_atletas, df_resultados, 'ID','id_atleta', cols_persona)
--- Iniciando consolidación de IDs ---
Se encontraron 373 nadadores con múltiples IDs. Unificando...
Se han actualizado 7691 registros en la tabla de resultados.
Se han eliminado 434 perfiles duplicados en la tabla de atletas.
------------------------------
4.3 Tratamiento de Tipos de Datos
Comenzamos transformando los tipos de datos de las columnas para asegurar que cada variable tiene el formato adecuado para análisis posterior.
4.3.1 Atletas
# Definimos el mapeo de tipos para df_atletastipos_atletas = {'ID': int,'NOMBRE': str,'APELLIDOS': str,'birthday': str, # Convertiremos a datetime en el siguiente paso'género': str}df_atletas = cambiar_tipos_datos(df_atletas, tipos_atletas)
### Conversión de Tipos de Datos
✓ Columna 'ID' convertida a int
✓ Columna 'NOMBRE' convertida a str
✓ Columna 'APELLIDOS' convertida a str
✓ Columna 'birthday' convertida a str
✓ Columna 'género' convertida a str
--------------------------------------------------
4.3.2 Clubes
# Definimos el mapeo de tipos para df_clubestipos_clubes = {'club_code': str,'club_name': str,'club_nation': str}df_clubes = cambiar_tipos_datos(df_clubes, tipos_clubes)
### Conversión de Tipos de Datos
✓ Columna 'club_code' convertida a str
✓ Columna 'club_name' convertida a str
✓ Columna 'club_nation' convertida a str
--------------------------------------------------
4.3.3 Competiciones
# Definimos el mapeo de tipos para df_competicionestipos_competiciones = {'ID': int,'nombre': str,'ciudad': str,'tipo_piscina': str,'pais': str,'fecha_inicio': str, # Convertiremos a datetime después'fecha_fin': str, # Convertiremos a datetime después'cronometraje': str,'numeroCalles': int}df_competiciones = cambiar_tipos_datos(df_competiciones, tipos_competiciones)
### Conversión de Tipos de Datos
✓ Columna 'ID' convertida a int
✓ Columna 'nombre' convertida a str
✓ Columna 'ciudad' convertida a str
✓ Columna 'tipo_piscina' convertida a str
✓ Columna 'pais' convertida a str
✓ Columna 'fecha_inicio' convertida a str
✓ Columna 'fecha_fin' convertida a str
✓ Columna 'cronometraje' convertida a str
✓ Columna 'numeroCalles' convertida a int
--------------------------------------------------
4.3.4 Resultados
# Definimos el mapeo de tipos para df_resultadostipos_resultados = {'id_competicion': int,'id_atleta': int,'club_code': str,'distancia': int,'estilo': str,'ronda': str,'tiempo_final': str, # Lo transformaremos en la sección de transformaciones'descalificado?': str,'puntos': int,'distancia_parcial': int,'tiempo_acumulado': str,'fecha': str, # Convertiremos a datetime después'hora': str# Convertiremos a datetime después}df_resultados = cambiar_tipos_datos(df_resultados, tipos_resultados)
### Conversión de Tipos de Datos
✓ Columna 'id_competicion' convertida a int
✓ Columna 'id_atleta' convertida a int
✓ Columna 'club_code' convertida a str
✓ Columna 'distancia' convertida a int
✓ Columna 'estilo' convertida a str
✓ Columna 'ronda' convertida a str
✓ Columna 'tiempo_final' convertida a str
✓ Columna 'descalificado?' convertida a str
✗ Error al convertir 'puntos': Cannot convert non-finite values (NA or inf) to integer
✗ Error al convertir 'distancia_parcial': Cannot convert non-finite values (NA or inf) to integer
✓ Columna 'tiempo_acumulado' convertida a str
✓ Columna 'fecha' convertida a str
✓ Columna 'hora' convertida a str
--------------------------------------------------
4.4 Manejo de Valores Nulos
Realizamos un análisis exhaustivo de valores faltantes en cada DataFrame y decidimos cómo tratarlos.
4.4.1 Atletas
verificar_nulos(df_atletas, 'Atletas')
### Análisis de Valores Nulos: Atletas
No se detectaron valores nulos en el DataFrame.
--------------------------------------------------
4.4.2 Clubes
verificar_nulos(df_clubes, 'Clubes')
### Análisis de Valores Nulos: Clubes
No se detectaron valores nulos en el DataFrame.
--------------------------------------------------
### Análisis de Valores Nulos: Competiciones
No se detectaron valores nulos en el DataFrame.
--------------------------------------------------
4.4.4 Resultados
verificar_nulos(df_resultados, 'Resultados')
### Análisis de Valores Nulos: Resultados
Columna Cantidad Porcentaje
puntos 74754 41.600258
distancia_parcial 1972 1.097409
--------------------------------------------------
Analizando los datos faltantes, se identifican 2 variables con valores de este tipo:
Puntos: Hace referencia a los puntos FINA conseguidos y asignados a su tiempo final. La ausencia de este dato tiene naturaleza mixta, por un lado, aquellos nadadores descalificados, no obtienen puntos, asignándose el valor faltante a esa variable. Por otro lado, una gran parte de los nulos restantes se deben a que ciertas competiciones registradas simplemente no calcularon este dato y no lo registraron.
distancia_parcial: Valores nulos en la distancia parcial para aquellos nadadores que no tienen valores de parcial. Son nadadores descalificados que no llegaron a completar una parte de la prueba, es por ello que dicha variable tiene datos faltantes.
4.5 Limpieza de Espacios en Blanco
Aseguramos la eliminación de posibles espacios en blanco superfluos en las columnas de texto de todos los DataFrames.
### Limpieza de Espacios en Blanco
Se procesaron 4 columnas de tipo texto.
--------------------------------------------------
### Limpieza de Espacios en Blanco
Se procesaron 3 columnas de tipo texto.
--------------------------------------------------
### Limpieza de Espacios en Blanco
Se procesaron 7 columnas de tipo texto.
--------------------------------------------------
### Limpieza de Espacios en Blanco
Se procesaron 8 columnas de tipo texto.
--------------------------------------------------
4.6 Transformación de Fechas
Convertimos las columnas de fechas al formato datetime para facilitar análisis temporales.
4.6.1 Atletas
# Convertimos la fecha de nacimientodf_atletas = transformar_fechas(df_atletas, ['birthday'])# Renombramos la columna para mayor claridaddf_atletas.rename(columns={'birthday': 'fecha_nacimiento'}, inplace=True)
### Transformación de Fechas
✓ Columna 'birthday' convertida a datetime
--------------------------------------------------
4.6.2 Competiciones
# Convertimos las fechas de inicio y fin de las competicionesdf_competiciones = transformar_fechas(df_competiciones, ['fecha_inicio', 'fecha_fin'])
### Transformación de Fechas
✓ Columna 'fecha_inicio' convertida a datetime
✓ Columna 'fecha_fin' convertida a datetime
--------------------------------------------------
4.6.3 Resultados
# Convertimos la fecha y hora de los resultadosdf_resultados = transformar_fechas(df_resultados, ['fecha'], columna_hora='hora')
### Transformación de Fechas
✓ Columna 'fecha' convertida a datetime
✓ Columna 'fecha_hora' creada combinando 'fecha' y 'hora'
--------------------------------------------------
Al disponer de la fecha y hora, la función nos devuelve el datetime completo en la columna fecha_hora.
4.7 Transformaciones de Texto
Realizamos transformaciones en variables de texto para estandarizarlas y mejorar su calidad.
4.7.1 Normalización de Géneros
# Verificamos valores únicos antes de la transformaciónprint(f"Valores únicos en 'género': {df_atletas['género'].unique()}")# Estandarizamos los valores (Hombre/Mujer, H/M, etc.)df_atletas['género'] = df_atletas['género'].str.upper()df_atletas['género'] = df_atletas['género'].replace({'M': 'HOMBRE','F': 'MUJER','MALE': 'HOMBRE','FEMALE': 'MUJER'})print(f"Valores normalizados: {df_atletas['género'].unique()}")print("-"*50)
Como los valores para género son únicos y correctos (no existen valores diferentes o en minúsculas), no es necesario realizar ninguna transformación adicional.
4.7.2 Normalización de Pruebas y Rondas
print("### Normalización de Pruebas y Rondas")# Convertimos a mayúsculas para estandarizacióndf_resultados['estilo'] = df_resultados['estilo'].str.upper()df_resultados['ronda'] = df_resultados['ronda'].str.upper()print(f"Pruebas únicas (primeras 10): {df_resultados['estilo'].unique()[:10]}")print(f"Rondas únicas: {df_resultados['ronda'].unique()}")print("-"*50)
### Normalización de Pruebas y Rondas
Pruebas únicas (primeras 10): ['BREAST' 'FLY' 'FREE' 'MEDLEY' 'BACK']
Rondas únicas: ['PRE' 'SEM' 'FIN' 'SOP' 'SOS' 'FHT']
--------------------------------------------------
Se asegura la unicidad de los valores en las columnas estilo y ronda tras la normalización transformando las cadenas de texto en mayúsculas.
4.8 Creación de Variables Derivadas
Creamos nuevas variables que serán útiles para el análisis posterior.
4.8.1 Duración de Competiciones
print("### Creación de Variable: Duración de Competiciones")# Calculamos la duración en días de cada competicióndf_competiciones['duracion_dias'] = (df_competiciones['fecha_fin'] - df_competiciones['fecha_inicio']).dt.days +1print(f"Duración mínima: {df_competiciones['duracion_dias'].min()} días")print(f"Duración máxima: {df_competiciones['duracion_dias'].max()} días")print(f"Duración promedio: {df_competiciones['duracion_dias'].mean():.1f} días")print("-"*50)
### Creación de Variable: Duración de Competiciones
Duración mínima: 4 días
Duración máxima: 9 días
Duración promedio: 5.5 días
--------------------------------------------------
4.8.2 Parseo de Tiempos
def parse_resultados_temporal(df):# trabajar sobre copia df = df.copy()# Normalizar separador decimal en las columnas de tiempo (si hubiera comas) df['tiempo_final'] = df['tiempo_final'].astype(str).str.replace(',', '.', regex=False) df['tiempo_acumulado'] = df.get('tiempo_acumulado', pd.Series(dtype=str)).astype(str).str.replace(',', '.', regex=False)# Combinar fecha + hora -> datetime (ajusta format si tu formato difiere) df['fecha_hora'] = pd.to_datetime( df['fecha'].astype(str) +' '+ df['hora'].astype(str),format='%Y-%m-%d %H:%M', errors='coerce' )# Convertir tiempos "HH:MM:SS.ss" a Timedelta y después a segundos (float) df['tiempo_final_td'] = pd.to_timedelta(df['tiempo_final'], errors='coerce') df['tiempo_final_seg'] = df['tiempo_final_td'].dt.total_seconds()if'tiempo_acumulado'in df.columns: df['tiempo_acumulado_td'] = pd.to_timedelta(df['tiempo_acumulado'], errors='coerce') df['tiempo_acumulado_seg'] = df['tiempo_acumulado_td'].dt.total_seconds()# Extracción de variables temporales (evitar acentos en nombres de columna) df['anio_competicion'] = df['fecha_hora'].dt.year df['mes_competicion'] = df['fecha_hora'].dt.month df['dia_semana'] = df['fecha_hora'].dt.day_name()return df# Aplicamos la función a la columna de tiemposdf_resultados = parse_resultados_temporal(df_resultados)print(f"Tiempos convertidos exitosamente")print(f"Tiempo mínimo final: {df_resultados['tiempo_final_seg'].min():.2f} segundos")print(f"Tiempo máximo final: {df_resultados['tiempo_final_seg'].max():.2f} segundos")print(f"Tiempos finales faltantes: {df_resultados['tiempo_final_seg'].isnull().sum()}")print(f"Tiempo mínimo acumulado: {df_resultados['tiempo_acumulado_seg'].min():.2f} segundos")print(f"Tiempo máximo acumulado: {df_resultados['tiempo_acumulado_seg'].max():.2f} segundos")print(f"Tiempos acumulados faltantes: {df_resultados['tiempo_acumulado_seg'].isnull().sum()}")print("-"*50)
Tiempos convertidos exitosamente
Tiempo mínimo final: 21.04 segundos
Tiempo máximo final: 1169.09 segundos
Tiempos finales faltantes: 1680
Tiempo mínimo acumulado: 21.04 segundos
Tiempo máximo acumulado: 1169.09 segundos
Tiempos acumulados faltantes: 2065
--------------------------------------------------
4.8.3 Extracción de Información Temporal
print("### Extracción de Variables Temporales")# Extraemos el año de las competicionesdf_resultados['anio_competicion'] = df_resultados['fecha'].dt.yeardf_resultados['mes_competicion'] = df_resultados['fecha'].dt.monthdf_resultados['dia_semana'] = df_resultados['fecha'].dt.day_name()# Extraemos también de las competicionesdf_competiciones['anio'] = df_competiciones['fecha_inicio'].dt.yearprint(f"Años en competiciones: {sorted(df_competiciones['anio'].unique())}")print(f"Meses representados: {sorted(df_resultados['mes_competicion'].unique())}")print("-"*50)
### Extracción de Variables Temporales
Años en competiciones: [np.int32(2021), np.int32(2022), np.int32(2023), np.int32(2024)]
Meses representados: [np.int32(1), np.int32(2), np.int32(3), np.int32(4), np.int32(5), np.int32(6), np.int32(7), np.int32(8), np.int32(9), np.int32(11), np.int32(12)]
--------------------------------------------------
4.9 Resumen Post-Procesamiento
Finalmente, mostramos un resumen del estado de cada DataFrame tras todas las transformaciones realizadas.
### Resumen Post-Procesamiento: Atletas
Dimensiones: 4276 filas x 5 columnas
Valores nulos totales: 0
--- Tipos de datos ---
ID int64
NOMBRE object
APELLIDOS object
fecha_nacimiento datetime64[ns]
género object
dtype: object
==================================================
Tanto la variable ID como la variable edad se han convertido a tipo entero (int), mientras que la variable fecha_nacimiento se ha convertido a tipo fecha (datetime). Además, no se han detectado valores nulos en este DataFrame tras el pre-procesamiento.
### Resumen Post-Procesamiento: Clubes
Dimensiones: 1013 filas x 3 columnas
Valores nulos totales: 0
--- Tipos de datos ---
club_code object
club_name object
club_nation object
dtype: object
==================================================
El DataFrame df_clubes mantiene las mismas columnas y tipos de datos dado que todas sus columnas son cadenas de carácteres. Tampoco no se han detectado valores nulos tras el pre-procesamiento.
### Resumen Post-Procesamiento: Competiciones
Dimensiones: 21 filas x 11 columnas
Valores nulos totales: 0
--- Tipos de datos ---
ID int64
nombre object
ciudad object
tipo_piscina object
fecha_inicio datetime64[ns]
fecha_fin datetime64[ns]
pais object
cronometraje object
numeroCalles int64
duracion_dias int64
anio int32
dtype: object
==================================================
En el DataFrame df_competiciones se han visto transformadas las columnas fecha_inicio y fecha_fin a tipo fecha (datetime), y se ha añadido la columna derivada duracion_dias de tipo entero (int) y la columna anio de tipo entero también. También se han transformado a entero las columnas ID y numeroCalles. No se han detectado valores nulos tras el pre-procesamiento.
### Resumen Post-Procesamiento: Resultados
Dimensiones: 179696 filas x 21 columnas
Valores nulos totales: 84216
--- Tipos de datos ---
id_competicion int64
id_atleta int64
club_code object
distancia int64
estilo object
ronda object
tiempo_final object
descalificado? object
puntos float64
distancia_parcial float64
tiempo_acumulado object
fecha datetime64[ns]
hora object
fecha_hora datetime64[ns]
tiempo_final_td timedelta64[ns]
tiempo_final_seg float64
tiempo_acumulado_td timedelta64[ns]
tiempo_acumulado_seg float64
anio_competicion int32
mes_competicion int32
dia_semana object
dtype: object
==================================================
Por último, en el Dataframe de resultados, se han convertido las columnas id_competicion, id_atleta, puntos y distancia parcial a tipo entero (int). Además, se han añadido varias columnas derivadas: fecha_hora de tipo fecha (datetime) de las columnas fecha y hora, tiempo_final_seg y tiempo_acumulado_seg de tipo flotante (float), y las columnas anio_competicion, mes_competicion y dia_semana. También se han añadido las columnas tiempo_acumulado_td y tiempo_final_td de tipo timedelta por si fuera necesario.
5 Procesamiento y Análisis
Teniendo los conjuntos de datos ordenados, se procede a realizar operaciones que permitan combinarlos para poder hacer los análisis y obtener métricas interesantes para el rendimiento.
5.1 Operaciones de join
5.1.1 Atletas con sus resultados
En esta primera operación se quieren juntar los resultados de los atletas en cada competición en el que han participado con sus datos personales:
El df_atletas se junta con df_resultados para obtener una fila por cada resultado en resultados_atletas. Es decir, cada atleta tendrá tantas filas como resultados en las distintas competiciones de cada año.
Todos los resultados tienen algún atleta asociado. Sin embargo, puede haber algún atleta que no tenga todos los resultados registrados. Para no perder la información de ningún atleta se utiliza el left join.
resultados_atletas = pd.merge( df_resultados, df_atletas, left_on='id_atleta', right_on='ID', how='left')#Comprobar resultadosprint("Resultados de los atletas por prueba y competición:")print(resultados_atletas[['NOMBRE', 'APELLIDOS', 'fecha_nacimiento', 'género', 'distancia', 'estilo', 'ronda', 'tiempo_final', 'descalificado?', 'puntos']])
Resultados de los atletas por prueba y competición:
NOMBRE APELLIDOS fecha_nacimiento género distancia estilo \
0 Fahim ANWARI 1999-05-05 HOMBRE 100 BREAST
1 Fahim ANWARI 1999-05-05 HOMBRE 100 BREAST
2 Fahim ANWARI 1999-05-05 HOMBRE 50 BREAST
3 Paolo PRISKA 2004-09-15 HOMBRE 100 FLY
4 Paolo PRISKA 2004-09-15 HOMBRE 100 FLY
... ... ... ... ... ... ...
179691 Paige VAN DER WESTHUIZEN 2003-04-23 MUJER 100 FREE
179692 Paige VAN DER WESTHUIZEN 2003-04-23 MUJER 200 FREE
179693 Paige VAN DER WESTHUIZEN 2003-04-23 MUJER 200 FREE
179694 Paige VAN DER WESTHUIZEN 2003-04-23 MUJER 200 FREE
179695 Paige VAN DER WESTHUIZEN 2003-04-23 MUJER 200 FREE
ronda tiempo_final descalificado? puntos
0 PRE 00:01:10.86 No 517.0
1 PRE 00:01:10.86 No 517.0
2 PRE 00:00:31.83 No 541.0
3 PRE 00:00:59.01 No 588.0
4 PRE 00:00:59.01 No 588.0
... ... ... ... ...
179691 PRE 00:01:00.12 No 636.0
179692 PRE 00:02:09.85 No 658.0
179693 PRE 00:02:09.85 No 658.0
179694 PRE 00:02:09.85 No 658.0
179695 PRE 00:02:09.85 No 658.0
[179696 rows x 10 columns]
5.1.2 Clubes por competición
En esta segunda operación se busca juntar en una tabla todos los clubes que han participado en cada competición.
Para ello se juntan resultados_atletas (tiene el id de la competición y el código del club), df_competiciones (tiene el id de la competición y el nombre de la competición) y df_clubes (tiene el código del club y el nombre del club).
Se empieza haciendo un join entre resultados_atletas y df_competiciones en base al id de la competición. Se crea un df intermedio llamado competiciones_clubes_sin_nombre. A este df se le une df_clubes en base al código del club. El resultado es un df llamado clubes_competicion_ampliado en el que hay una fila por cada atleta que ha participado en cada competición representando a su club.
En los dos merges se utiliza outer join para mantener la información de todas las filas de las tres tablas en caso de que hubiera inconsistencias.
competiciones_clubes_sin_nombre = pd.merge(resultados_atletas, df_competiciones, right_on='ID',left_on='id_competicion', how='outer')clubes_competicion_ampliado = pd.merge(competiciones_clubes_sin_nombre, df_clubes, on='club_code', how='outer')#Comprobar resultadosprint("Representación de los clubes participantes por competición:")print(clubes_competicion_ampliado[['id_competicion', 'id_atleta', 'club_code', 'club_name', 'nombre', 'anio', 'ciudad']].head(10))
Representación de los clubes participantes por competición:
id_competicion id_atleta club_code club_name \
0 19.0 107.0 AA Alligator Aquatics
1 19.0 107.0 AA Alligator Aquatics
2 19.0 107.0 AA Alligator Aquatics
3 7.0 653.0 AAAA Alamo Area Aquatic Association
4 7.0 653.0 AAAA Alamo Area Aquatic Association
5 7.0 653.0 AAAA Alamo Area Aquatic Association
6 7.0 653.0 AAAA Alamo Area Aquatic Association
7 7.0 653.0 AAAA Alamo Area Aquatic Association
8 7.0 653.0 AAAA Alamo Area Aquatic Association
9 7.0 653.0 AAAA Alamo Area Aquatic Association
nombre anio ciudad
0 2023 TYR Pro Series - Westmont 2023.0 Westmont, IL
1 2023 TYR Pro Series - Westmont 2023.0 Westmont, IL
2 2023 TYR Pro Series - Westmont 2023.0 Westmont, IL
3 2022 Speedo Junior National Championships 2022.0 Irvine, CA
4 2022 Speedo Junior National Championships 2022.0 Irvine, CA
5 2022 Speedo Junior National Championships 2022.0 Irvine, CA
6 2022 Speedo Junior National Championships 2022.0 Irvine, CA
7 2022 Speedo Junior National Championships 2022.0 Irvine, CA
8 2022 Speedo Junior National Championships 2022.0 Irvine, CA
9 2022 Speedo Junior National Championships 2022.0 Irvine, CA
# Se borra el df intermedio para no sobrecargardel competiciones_clubes_sin_nombre
5.2 Operaciones de groupby
5.2.1 Estadísticas de participación en competiciones por club
El dataframe clubes_competicion_ampliado tiene una fila por cada participación del atleta que representa a su club en una competición. Es decir, si en un campeonato un nadador ha realizado 3 pruebas distintas, habrá tres filas distitntas.
En esta primera operación de groupby se busca obtener un dataframe en el que haya una fila por cada atleta que haya participado en una competición determinada, independientemente del número de pruebas en el que tenga un resultado registrado.
A partir de ahí se hará un conteo para identificar cuántos atletas distintos han representado a un club en una competición determinada.
representacion_clubes_competicion = clubes_competicion_ampliado.groupby( ['id_competicion', 'club_code'], as_index =False).agg({'id_atleta': 'nunique', #contar cuántos atletas distintos hay de cada club'club_name': 'first', #estando ya agrupados y habiendo contado los atletas distintos, se pone la primera vez que aparece el nombre de cada club (en las siguientes: de cada competición, su año y su lugar de realización)'nombre': 'first','anio': 'first','ciudad': 'first'})#Renombrar columnarepresentacion_clubes_competicion = representacion_clubes_competicion.rename( columns={'id_atleta': 'atletas_distintos'})print(representacion_clubes_competicion[['atletas_distintos', 'club_code', 'club_name', 'nombre', 'anio', 'ciudad']].head(10))
atletas_distintos club_code club_name \
0 1 AFG Afghanistan
1 4 ALB Albania
2 3 ALG Algeria
3 3 AND Andorra
4 3 ANG Angola
5 4 ANT Antigua and Barbuda
6 4 ARG Argentina
7 4 ARM Armenia
8 2 ART AQUA Refugee Team
9 2 ARU Aruba
nombre anio ciudad
0 World Aquatics Championships 2024.0 Doha
1 World Aquatics Championships 2024.0 Doha
2 World Aquatics Championships 2024.0 Doha
3 World Aquatics Championships 2024.0 Doha
4 World Aquatics Championships 2024.0 Doha
5 World Aquatics Championships 2024.0 Doha
6 World Aquatics Championships 2024.0 Doha
7 World Aquatics Championships 2024.0 Doha
8 World Aquatics Championships 2024.0 Doha
9 World Aquatics Championships 2024.0 Doha
5.2.2 Ranking de participación
A partir de aquí se puede encontrar el club con mayor representación en una competición y crear un ranking con los clubes que mayor paricipación han tenido:
maxima_participacion = representacion_clubes_competicion.loc[representacion_clubes_competicion["atletas_distintos"].idxmax()]print(f"El club con más participación ha sido: {maxima_participacion['club_name']} - {maxima_participacion['atletas_distintos']} nadadores.")maxima_participacion = representacion_clubes_competicion.sort_values(by="atletas_distintos", ascending =False)print(maxima_participacion.head(10))
El club con más participación ha sido: Unattached Sun Devil - 48 nadadores.
id_competicion club_code atletas_distintos club_name \
2384 15.0 UN-AZ 48 Unattached Sun Devil
3397 21.0 USA 37 United States
806 5.0 USA 36 United States
289 2.0 USA 36 United States
2320 15.0 IU 35 Indiana University
3250 21.0 CHN 35 China
2434 16.0 HUN 33 Hungary
3305 21.0 JPN 32 Japan
636 5.0 AUS 32 Australia
3211 20.0 WOLF 32 Wolfpack Elite
nombre anio ciudad
2384 2024 TYR Pro Swim Series 2024.0 San Antonio, TX
3397 World Aquatics Championships 2023.0 Fukuoka
806 19th FINA World Championships 2022.0 Budapest
289 World Aquatics Junior Swimming Championships 2023.0 Netanya
2320 2024 TYR Pro Swim Series 2024.0 San Antonio, TX
3250 World Aquatics Championships 2023.0 Fukuoka
2434 European Aquatics Championships Belgrade 2024 2024.0 Belgrade
3305 World Aquatics Championships 2023.0 Fukuoka
636 19th FINA World Championships 2022.0 Budapest
3211 2023 Phillips 66 National Championships 2023.0 Indianapolis, IN
5.2.3 Estadísticas de competición por atleta
En esta segunda operación de groupby se calculan las estadísticas de competición para cada atleta:
El número de competiciones distintas en las que han participado.
NOMBRE APELLIDOS fecha_nacimiento género \
ID anio_competicion
1 2021 Mac CLARK 2007-05-20 HOMBRE
2022 Mac CLARK 2007-05-20 HOMBRE
2023 Mac CLARK 2007-05-20 HOMBRE
2024 Mac CLARK 2007-05-20 HOMBRE
2 2021 Elise NARDOZZI 2006-01-18 MUJER
2022 Elise NARDOZZI 2006-01-18 MUJER
2023 Elise NARDOZZI 2006-01-18 MUJER
2024 Elise NARDOZZI 2006-01-18 MUJER
3 2021 Cooper VAN DER LAAN 1998-12-16 HOMBRE
2022 Cooper VAN DER LAAN 1998-12-16 HOMBRE
num_comp_realizadas total_carreras_realizadas \
ID anio_competicion
1 2021 3 2
2022 3 2
2023 4 3
2024 2 2
2 2021 3 2
2022 4 3
2023 4 2
2024 1 1
3 2021 3 3
2022 4 2
competiciones_por_anio
ID anio_competicion
1 2021 3
2022 3
2023 4
2024 2
2 2021 3
2022 4
2023 4
2024 1
3 2021 3
2022 4
5.2.5 Estadísticas de rendimiento por atleta
En este apartado se definen las siguientes métricas que perfilarán al nadador según su especialidad (distancia y estilo) y su rendimiento: - Número de veces que ha alcanzado una final. - En cuántos estilos es especialista y cuáles son. - En qué disciplina ha llegado más veces a una final.
#Definir disciplina y crear una columnaresultados_atletas["disciplina"] = (resultados_atletas["distancia"]).astype(str) +"m "+ resultados_atletas["estilo"]#Reordenar columnacols = resultados_atletas.columns.tolist()pos = cols.index("estilo") +1cols.insert(pos, cols.pop(cols.index("disciplina")))resultados_atletas = resultados_atletas[cols]
En esta sección se realiza el cálculo de 2 nuevas métricas:
Cálculo de la posición de cada atleta en la prueba nadada.
Porcentaje de Mejora: Calcularemos cuánto ha mejorado en % un nadador su tiempo desde su primera vez que nadó hasta su mejor marca personal en el ciclo.
Para la primera métrica, vamos a realizar el cálculo en el dataframe creado anteriormente, resultados_atletas. Para ello, vamos a agrupar por competición, estilo, distancia, ronda y género. Además, asignaremos la misma posición a empates, por lo que usamos el método ‘min’ al rankear.
Para la segunda métrica, vamos a recoger primero por atleta y disciplina, sus tiempos máximo y mínimo del ciclo olímpico. Tras ello, calculamos el porcentaje de mejora y luego realizamos un merge con el dataframe con el objetivo de tener dicho porcentaje de mejora.
# 1. Cálculo de la posición (Ranking)resultados_atletas['posicion'] = ( resultados_atletas.groupby(['id_competicion', 'distancia', 'estilo', 'ronda', 'género'])['tiempo_final_seg'] .rank(method='min', ascending=True))print("Ejemplo de posiciones calculadas:")display(resultados_atletas[['id_competicion', 'disciplina', 'ronda', 'tiempo_final', 'posicion']].head())# 2. Porcentaje de Mejoramejora_df = resultados_atletas.groupby(['ID', 'disciplina'])['tiempo_final_seg'].agg(['max', 'min']).reset_index()mejora_df['porcentaje_mejora'] = ((mejora_df['max'] - mejora_df['min']) / mejora_df['max']) *100# Unimos esta métrica al df de rendimiento anteriordf_atletas = df_atletas.merge( mejora_df.groupby('ID')['porcentaje_mejora'].mean().reset_index(), # Promedio de mejora en todas sus disciplinas on='ID', how='left')df_atletas.rename(columns={'porcentaje_mejora': 'promedio_mejora_global'}, inplace=True)
Ejemplo de posiciones calculadas:
id_competicion
disciplina
ronda
tiempo_final
posicion
0
1
100m BREAST
PRE
00:01:10.86
140.0
1
1
100m BREAST
PRE
00:01:10.86
140.0
2
1
50m BREAST
PRE
00:00:31.83
50.0
3
1
100m FLY
PRE
00:00:59.01
113.0
4
1
100m FLY
PRE
00:00:59.01
113.0
6 Análisis y Visualizaciones
En esta etapa, utilizaremos la librería plotly para generar gráficos interactivos que nos permitan inspeccionar a través de los datos.
import plotly.express as pximport plotly.graph_objects as gotemplate_design ="plotly_white"
6.1 Análisis de Parciales
¿Cómo se distribuye la carrera de un nadador a través de sus parciales?
A continuación, vamos a seleccionar un 400m FREE de la nadadora Katie Ledecky, y vamos a graficar sus parciales a lo largo de la prueba.
import numpy as np# 1. Configuración de la búsquedatarget_nombre ="Katie"target_apellido ="LEDECKY"target_disciplina ="400m FREE"# 2. Filtrado del DataFramedf_swimmer = resultados_atletas[ (resultados_atletas['NOMBRE'] == target_nombre) & (resultados_atletas['APELLIDOS'].str.contains(target_apellido, case=False)) & (resultados_atletas['disciplina'].str.contains("400m FREE")) # Flexible por si es FREESTYLE o FREE]# Verificamos que existan datosif df_swimmer.empty:print(f"No se encontraron datos para {target_nombre}{target_apellido} en {target_disciplina}.")else:# 3. Selección Aleatoria de Competición ids_competiciones = df_swimmer['id_competicion'].unique()# Seleccionamos uno aleatoriamente id_random = np.random.choice(ids_competiciones) df_carrera = df_swimmer[df_swimmer['id_competicion'] == id_random].copy()if'FHT'in df_carrera['ronda'].values: df_carrera = df_carrera[df_carrera['ronda'] =='FHT']else:# Si no hay final, cogemos la primera ronda disponible (ej. PRE) ronda_disp = df_carrera['ronda'].iloc[0] df_carrera = df_carrera[df_carrera['ronda'] == ronda_disp] df_carrera = df_carrera.sort_values('distancia_parcial')# 4. Cálculo del Tiempo del Parcial (Split) df_carrera['tiempo_parcial_calculado'] = df_carrera['tiempo_acumulado_seg'].diff().fillna(df_carrera['tiempo_acumulado_seg'])# Datos para el títuloprint(f"Graficando: {target_nombre}{target_apellido} | {target_disciplina}")print(f"Competición ID: {id_random}")# 5. Graficar fig_parciales = px.line( df_carrera, x='distancia_parcial', y='tiempo_parcial_calculado', markers=True, text=df_carrera['tiempo_parcial_calculado'].round(2), # Mostrar valor en el punto title=f"Ritmo de Carrera (Splits): {target_nombre}{target_apellido}<br><sup>{target_disciplina}</sup>", labels={'distancia_parcial': 'Distancia (m)', 'tiempo_parcial_calculado': 'Tiempo de Vuelta (s)' }, template="plotly_white" )# Ajuste para que las etiquetas se lean bien fig_parciales.update_traces(textposition="top center")# Ajustar eje Y para que no empiece necesariamente en 0 y se vea mejor la variación del ritmo fig_parciales.update_yaxes(rangemode="tozero") fig_parciales.show()
6.3 Participación por Clubes en Competiciones Clave.
Visualizamos la representación de los clubes o países en una competición específica para entender el evento:
target_comp_id =1df_participacion = representacion_clubes_competicion[ representacion_clubes_competicion['id_competicion'] == target_comp_id].copy()df_top10 = df_participacion.sort_values('atletas_distintos', ascending=False).head(10)nombre_competicion = df_top10['nombre'].iloc[0]anio_competicion = df_top10['anio'].iloc[0]print(f"Graficando Top 10 para: {nombre_competicion} ({anio_competicion})")# 4. Graficarfig_part = px.bar( df_top10, x='club_name', # Eje X: Nombre del Club/País y='atletas_distintos', # Eje Y: Cantidad de nadadores text_auto=True, # Muestra el número encima de la barra automáticamente color='atletas_distintos', # Colorea según la cantidad (opcional, queda bonito) title=f"Top 10 Participación: {nombre_competicion}", labels={'club_name': 'Club / País','atletas_distintos': 'Nº Atletas' }, template="plotly_white")# Ajustes de diseñofig_part.update_layout( xaxis_tickangle=-45, # Inclina los nombres si son muy largos showlegend=False# Ocultamos la leyenda porque el color ya da la info)fig_part.show()
Graficando Top 10 para: World Aquatics Championships (2024.0)
6.4 Preguntas directas sobre los datos
6.4.1 ¿Qué nadador ha tenido la mayor mejora porcentual promedio?
print(f"\nTop 1 nadador con mayor % de mejora promedio:")display(df_atletas.sort_values('promedio_mejora_global', ascending=False).head(1))
Top 1 nadador con mayor % de mejora promedio:
ID
NOMBRE
APELLIDOS
fecha_nacimiento
género
promedio_mejora_global
2908
1025
Joseph
MILLER
2004-10-24
HOMBRE
18.858561
6.4.2 ¿Cuáles son la prueba más rápida del ciclo (menor tiempo registrado)?
idx_min = resultados_atletas['tiempo_final_seg'].idxmin()registro_record = resultados_atletas.loc[idx_min]tiempo_str = registro_record['tiempo_final'] # Para mostrarlo bonito (texto)# 4. Imprimir resultadoprint(f"El tiempo final más bajo ({tiempo_str}) se realizó en la prueba {registro_record['disciplina']}")
El tiempo final más bajo (00:00:21.04) se realizó en la prueba 50m FREE
6.4.3 ¿Qué nadador ha competido más veces durante el ciclo? ¿Y menos?
participacion_atletas = resultados_atletas.groupby(['id_atleta', 'NOMBRE', 'APELLIDOS'])['id_competicion'].nunique().reset_index()participacion_atletas.rename(columns={'id_competicion': 'num_competiciones'}, inplace=True)max_comps = participacion_atletas['num_competiciones'].max()min_comps = participacion_atletas['num_competiciones'].min()nadadores_max = participacion_atletas[participacion_atletas['num_competiciones'] == max_comps]nadadores_min_count =len(participacion_atletas[participacion_atletas['num_competiciones'] == min_comps])print(f"--- Análisis de Asistencia ---")print(f"El récord de asistencia es de {max_comps} competiciones.")print("Nadador(es) con más participaciones:")for index, row in nadadores_max.iterrows():print(f"- {row['NOMBRE']}{row['APELLIDOS']}")print("-"*30)print(f"El mínimo de asistencia es de {min_comps} competición.")print(f"Hay {nadadores_min_count} nadadores que solo han acudido a una única competición registrada en el ciclo.")
--- Análisis de Asistencia ---
El récord de asistencia es de 16 competiciones.
Nadador(es) con más participaciones:
- Yeziel MORALES
- Krzysztof CHMIELEWSKI
- Nic FINK
- Rafael MIROSLAW
- Denis LOKTEV
- Denis PETRASHOV
------------------------------
El mínimo de asistencia es de 1 competición.
Hay 2060 nadadores que solo han acudido a una única competición registrada en el ciclo.
7 Carga de Datos
En la última fase del proceso ETL, se almacenan los DataFrames procesados en archivos CSV para su uso futuro.
output_dir ="data/final_data/"os.makedirs(output_dir, exist_ok=True)# 1. DataFrames Base Procesadosdf_atletas.to_csv(f"{output_dir}atletas_procesado.csv", index=False)df_clubes.to_csv(f"{output_dir}clubes_procesado.csv", index=False)df_competiciones.to_csv(f"{output_dir}competiciones_procesado.csv", index=False)df_resultados.to_csv(f"{output_dir}resultados_procesado.csv", index=False)# 2. DataFrames de Análisisresultados_atletas.to_csv(f"{output_dir}resultados_atletas.csv", index=False)clubes_competicion_ampliado.to_csv(f"{output_dir}clubes_competicion_ampliado.csv", index=False)representacion_clubes_competicion.to_csv(f"{output_dir}representacion_clubes_competicion.csv", index=False)estadisticas_atleta_competicion.to_csv(f"{output_dir}estadisticas_atleta_competicion.csv", index=False)estadisticas_atleta_temporada.to_csv(f"{output_dir}estadisticas_atleta_temporada.csv", index=False)estadisticas_rendimiento.to_csv(f"{output_dir}estadisticas_rendimiento.csv", index=False)print("="*50)print("CARGA COMPLETADA")print("="*50)print(f"\nSe han guardado {10} archivos CSV en: {output_dir}")print("\nDataFrames Base con transformaciones:")print(" - atletas_procesado.csv")print(" - clubes_procesado.csv")print(" - competiciones_procesado.csv")print(" - resultados_procesado.csv")print("\nDataFrames de Análisis:")print(" - resultados_atletas.csv")print(" - clubes_competicion_ampliado.csv")print(" - representacion_clubes_competicion.csv")print(" - estadisticas_atleta_competicion.csv")print(" - estadisticas_atleta_temporada.csv")print(" - estadisticas_rendimiento.csv")
==================================================
CARGA COMPLETADA
==================================================
Se han guardado 10 archivos CSV en: data/final_data/
DataFrames Base con transformaciones:
- atletas_procesado.csv
- clubes_procesado.csv
- competiciones_procesado.csv
- resultados_procesado.csv
DataFrames de Análisis:
- resultados_atletas.csv
- clubes_competicion_ampliado.csv
- representacion_clubes_competicion.csv
- estadisticas_atleta_competicion.csv
- estadisticas_atleta_temporada.csv
- estadisticas_rendimiento.csv
8 Conclusiones
Tras la ejecución del flujo ETL y el análisis exploratorio, exraemos las siguientes conclusiones:
Calidad de los datos: Se ha logrado homogeneizar distintas competiciones. La conversión de tiempos a segundos y el procesamiento de tipos para fechas permiten ahora operaciones precisas sobre el rendimiento de los nadadores.
Métricas de valor: La métrica de “Porcentaje de Mejora Global” revela cómo ha influido el ciclo olímpico en todos y cada uno de los nadadores.
Estrategias de la prueba: A través de las visualizaciones de parciales, se puede observar cómo desarrolla un nadador una determinada prueba, logrando así que se puedan observar posibles planteamientos de la prueba nadada.
La limpieza de datos ha expuesto inconsistencias que inicialmente no se conocían, como la nomenclatura de clubes, lo que justica la necesidad de la tabla df_clubes normalizada. El análisis de asistencia muestra cómo la mayor parte de los nadadores sólo compiten en una sóla prueba, debido a que estamos unificando pruebas como mundiales, junto con nacionales o tomas de tiempo que tienen un nivel de entrada más flexible.
Algunas extensiones a futuro de este trabajo pueden ser:
Machine Learning: Para predecir tiempos finales de un nadador o carreras enteras. También usable para clasificar a través de herramientas de aprendizaje no supervisado a tipos de nadadores en función de las pruebas que nadan, los tiempos que realizan o la forma de plantear sus pruebas.
Dashboarding: Conectar todos los dataframes creados para hacer una página web en la que cualquier nadador/club pueda consultar sus tiempos en cuestión de segundos gracias a la eficiencia de hacer joins entre las distintas tablas maestras.